<!DOCTYPE html>
<html lang="en">
  <head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">


  <title>TDD开发容器化的Python微服务应用(一)-blog.qikqiak.com|阳明的博客|Kubernetes|Docker|Python|Golang|Cloud Native</title>
  <meta property="og:title" content="TDD开发容器化的Python微服务应用(一)" />
  <meta name="twitter:title" content="TDD开发容器化的Python微服务应用(一)" />

  <meta name="description" content="在这个课程中，你将学习如何使用Docker快速创建开发环境、管理多个微服务，应用程序在本地运行后，您将学习怎样在生产环境部署应用。我们也会练习TDD(测试驱动开发)，在你的项目中测试先行，我们重点将放在服务端的单元测试、功能和集成测试以及端到端的测试上面，以确保整个系统按预期工作。

">
  <meta property="og:description" content="在这个课程中，你将学习如何使用Docker快速创建开发环境、管理多个微服务，应用程序在本地运行后，您将学习怎样在生产环境部署应用。我们也会练习TDD(测试驱动开发)，在你的项目中测试先行，我们重点将放在服务端的单元测试、功能和集成测试以及端到端的测试上面，以确保整个系统按预期工作。

">
  <meta name="twitter:description" content="在这个课程中，你将学习如何使用Docker快速创建开发环境、管理多个微服务，应用程序在本地运行后，您将学习怎样在生产环境部署应用。我们也会练习TDD(测试驱动开发)，在你的项目中测试先行，我们重点将放在服务端的单元测试、功能和集成测试以及端到端的测试上面，以确保整个系统按预期工作。

">
  <meta name="author" content=""/>
  <link href='https://blog.qikqiak.com/img/favicon.ico' rel='icon' type='image/x-icon'/>
  <meta property="og:image" content="https://blog.qikqiak.com/img/avatar.jpeg" />
  <meta name="twitter:image" content="https://blog.qikqiak.com/img/avatar.jpeg" />
  <meta name="twitter:card" content="summary" />
  <meta property="og:url" content="https://blog.qikqiak.com/post/tdd-develop-python-microservice-app/" />
  <meta property="og:type" content="website" />
  <meta property="og:site_name" content="阳明的博客" />

  <meta name="generator" content="Hugo 0.34" />
  <link rel="canonical" href="https://blog.qikqiak.com/post/tdd-develop-python-microservice-app/" />
  <link rel="alternate" href="https://blog.qikqiak.com/index.xml" type="application/rss+xml" title="阳明的博客">
  <link href="https://cdn.bootcss.com/KaTeX/0.7.1/katex.min.css" rel="stylesheet">
  <link href="https://cdn.bootcss.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
  <link href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
  <link rel="stylesheet" href="https://blog.qikqiak.com/css/main.css?t=1235" />
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic" />
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,300,600,700,800" />
  <link rel="stylesheet" href="https://blog.qikqiak.com/css/pygment_highlights.css" />
  <link rel="stylesheet" href="https://blog.qikqiak.com/css/highlight.min.css" />
  <link rel="stylesheet" href="https://blog.qikqiak.com/css/prism.css?t=123" />
  <link rel="stylesheet" href="https://blog.qikqiak.com/css/search.css" />
<meta name="keywords" content="">
<script>
var _hmt = _hmt || [];
(function() {
  var hm = document.createElement("script");
  hm.src = "https://hm.baidu.com/hm.js?970de9920f6089c287a7fd366138d4e9";
  var s = document.getElementsByTagName("script")[0];
  s.parentNode.insertBefore(hm, s);
})();
</script>



<link href="https://cdn.bootcss.com/photoswipe/4.1.1/photoswipe.min.css" rel="stylesheet">
<link href="https://cdn.bootcss.com/photoswipe/4.1.1/default-skin/default-skin.min.css" rel="stylesheet">



<div class="pswp" tabindex="-1" role="dialog" aria-hidden="true">

<div class="pswp__bg"></div>

<div class="pswp__scroll-wrap">
    
    <div class="pswp__container">
      <div class="pswp__item"></div>
      <div class="pswp__item"></div>
      <div class="pswp__item"></div>
    </div>
    
    <div class="pswp__ui pswp__ui--hidden">
    <div class="pswp__top-bar">
      
      <div class="pswp__counter"></div>
      <button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
      <button class="pswp__button pswp__button--share" title="Share"></button>
      <button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
      <button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
      
      
      <div class="pswp__preloader">
        <div class="pswp__preloader__icn">
          <div class="pswp__preloader__cut">
            <div class="pswp__preloader__donut"></div>
          </div>
        </div>
      </div>
    </div>
    <div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
      <div class="pswp__share-tooltip"></div>
    </div>
    <button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)">
    </button>
    <button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)">
    </button>
    <div class="pswp__caption">
      <div class="pswp__caption__center"></div>
    </div>
    </div>
    </div>
</div>

<script>
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', 'UA-69668147-3', 'auto');
ga('send', 'pageview');
</script>
<script async src='//www.google-analytics.com/analytics.js'></script>

</head>

  <body>
    <nav class="navbar navbar-default navbar-fixed-top navbar-custom">
  <div class="container-fluid">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#main-navbar">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="https://blog.qikqiak.com/">阳明的博客</a>
    </div>

    <div class="collapse navbar-collapse" id="main-navbar">
      <ul class="nav navbar-nav navbar-right">
        
          
            <li>
              <a title="Home" href="https://ydzs.io">Home</a>
            </li>
          
        
          
            <li class="navlinks-container">
              <a class="navlinks-parent" href="javascript:void(0)">Categories</a>
              <div class="navlinks-children">
                
                  <a href="https://blog.qikqiak.com/page/archive/">Archive</a>
                
                  <a href="https://blog.qikqiak.com/tags">tags</a>
                
                  <a href="https://blog.qikqiak.com/tags/kubernetes">kubernetes</a>
                
                  <a href="https://blog.qikqiak.com/tags/python">python</a>
                
                  <a href="https://blog.qikqiak.com/tags/django">django</a>
                
                  <a href="https://blog.qikqiak.com/tags/ops">devops</a>
                
              </div>
            </li>
          
        
          
            <li class="navlinks-container">
              <a class="navlinks-parent" href="javascript:void(0)">海马学院</a>
              <div class="navlinks-children">
                
                  <a href="https://www.haimaxy.com/course/6y02wp/?utm_source=blog&amp;utm_campaign=referral&amp;utm_medium=topmenu">Python微服务实战</a>
                
                  <a href="https://www.haimaxy.com/course/pww13p/?utm_source=blog&amp;utm_campaign=referral&amp;utm_medium=topmenu">Webpack3.x 入门</a>
                
                  <a href="https://www.haimaxy.com/course/pjrqxm/?utm_source=blog&amp;utm_campaign=referral&amp;utm_medium=topmenu">Kubernetes 集群搭建</a>
                
              </div>
            </li>
          
        
          
            <li>
              <a title="About" href="https://blog.qikqiak.com/page/about/">About</a>
            </li>
          
        

        

        

        
          <li>
            <a href="#modalSearch" data-toggle="modal" data-target="#modalSearch" style="outline: none;">
              <span class="hidden-sm hidden-md hidden-lg">Search</span> <span id="searchGlyph" class="glyphicon glyphicon-search"></span>
            </a>
          </li>
          

      </ul>
    </div>

    <div class="avatar-container">
      <div class="avatar-img-border">
        
          <a title="阳明的博客" href="https://blog.qikqiak.com/">
            <img class="avatar-img" src="https://blog.qikqiak.com/img/avatar.jpeg" alt="阳明的博客" />
          </a>
        
      </div>
    </div>

  </div>
</nav>





  <div id="modalSearch" class="modal fade" role="dialog">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <button type="button" class="close" data-dismiss="modal">&times;</button>
          <h4 class="modal-title">Search blog.qikqiak.com</h4>
        </div>
        <div class="modal-body">
            
<div class="aa-input-container" id="aa-input-container">
    <input type="search" id="aa-search-input" class="aa-input-search" placeholder="Search for titles or URIs..." name="search" autocomplete="off" />
    <svg class="aa-input-icon" viewBox="654 -372 1664 1664">
        <path d="M1806,332c0-123.3-43.8-228.8-131.5-316.5C1586.8-72.2,1481.3-116,1358-116s-228.8,43.8-316.5,131.5  C953.8,103.2,910,208.7,910,332s43.8,228.8,131.5,316.5C1129.2,736.2,1234.7,780,1358,780s228.8-43.8,316.5-131.5  C1762.2,560.8,1806,455.3,1806,332z M2318,1164c0,34.7-12.7,64.7-38,90s-55.3,38-90,38c-36,0-66-12.7-90-38l-343-342  c-119.3,82.7-252.3,124-399,124c-95.3,0-186.5-18.5-273.5-55.5s-162-87-225-150s-113-138-150-225S654,427.3,654,332  s18.5-186.5,55.5-273.5s87-162,150-225s138-113,225-150S1262.7-372,1358-372s186.5,18.5,273.5,55.5s162,87,225,150s113,138,150,225  S2062,236.7,2062,332c0,146.7-41.3,279.7-124,399l343,343C2305.7,1098.7,2318,1128.7,2318,1164z" />
    </svg>
</div>
<script src="https://res.cloudinary.com/jimmysong/raw/upload/rootsongjc-hugo/algoliasearch.min.js"></script>
<script src="https://res.cloudinary.com/jimmysong/raw/upload/rootsongjc-hugo/autocomplete.min.js"></script>

<script>
var client = algoliasearch("1JDRAS0AZR", "8804ac109158bb3bb60d74ce98fa332f");
var index = client.initIndex('prod_blog');

autocomplete('#aa-search-input',
{ hint: false}, {
    source: autocomplete.sources.hits(index, {hitsPerPage: 5}),
    
    displayKey: 'name',
    
    templates: {
        
        suggestion: function(suggestion) {
            return '<span>' + '<a href="https://blog.qikqiak.com/post/' + suggestion.slug + '">' +
            suggestion._highlightResult.title.value + '</a></span>';
        }
    }
});
</script>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-default" data-dismiss="modal">close</button>
        </div>
      </div>
    </div>
  </div>

    
  
  
  




  
    <div id="header-big-imgs" data-num-img=1 data-img-src-1="https://blog.qikqiak.com/img/posts/photo-1470020337050-543c4e581988.jpeg" data-img-desc-1="Cloudy Cabin Escape."></div>
  

  <header class="header-section has-img">
    
      <div class="intro-header big-img">
        
        <div class="container">
          <div class="row">
            <div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
              <div class="post-heading">
                <h1>TDD开发容器化的Python微服务应用(一)</h1>
                  
                  
                    <span class="post-meta">
  Posted on December 28, 2017
  
</span>


                  
              </div>
            </div>
          </div>
        </div>
        <span class="img-desc" style="display: inline;"></span>
      </div>
    
    <div class="intro-header no-img">
      
      <div class="container">
        <div class="row">
          <div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
            <div class="post-heading">
              <h1>TDD开发容器化的Python微服务应用(一)</h1>
                
                
                  <span class="post-meta">
  Posted on December 28, 2017
  
</span>


                
            </div>
          </div>
        </div>
      </div>
    </div>
  </header>


    
<div class="container" role="main">
  <div class="row">
    <div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">

      <div>
          
          
          <h5 id="tags" style="margin-top: 0px;">标签:
            
                <a href="https://blog.qikqiak.com/tags/python/">python</a> &nbsp;
            
                <a href="https://blog.qikqiak.com/tags/flask/">flask</a> &nbsp;
            
                <a href="https://blog.qikqiak.com/tags/tdd/">TDD</a> &nbsp;
            
                <a href="https://blog.qikqiak.com/tags/docker/">docker</a> &nbsp;
            
                <a href="https://blog.qikqiak.com/tags/react/">react</a> &nbsp;
            
                <a href="https://blog.qikqiak.com/tags/kubernetes/">kubernetes</a> &nbsp;
            
                <a href="https://blog.qikqiak.com/tags/microservice/">Microservice</a> &nbsp;
            
          </h5>
          
      </div>

      <article role="main" class="blog-post">
        <p>在这个课程中，你将学习如何使用<code>Docker</code>快速创建开发环境、管理多个微服务，应用程序在本地运行后，您将学习怎样在生产环境部署应用。我们也会练习<code>TDD</code>(测试驱动开发)，在你的项目中测试先行，我们重点将放在服务端的单元测试、功能和集成测试以及端到端的测试上面，以确保整个系统按预期工作。</p>

<p></p>

<blockquote>
<p>有人问我为什么这么长的文章不分拆成几篇文章啊？这样阅读起来也方便啊，然而在我自己学习的过程中，这种整个一篇文章把一件事情从头到尾讲清楚的形式是最好的，能给读者提供一种<code>沉浸式</code>的学习体验，阅读完整个文章后有种<code>酣畅淋漓</code>的感觉，所以我选择这种一篇文章的形式。</p>
</blockquote>

<p>扫描下面的二维码(或微信搜索<code>iEverything</code>)添加我微信好友(注明python)，然后可以加入到我们的<code>python</code>讨论群里面共同学习
<img src="https://blog.qikqiak.com/img/posts/wexin-qrcode.jpeg" alt="qrcode" /></p>

<h2 id="目录">目录</h2>

<ol>
<li><a href="#introduction">介绍</a></li>
<li><a href="#start">开始</a></li>
<li><a href="#docker">Docker 配置</a></li>
<li><a href="#database">数据库安装配置</a></li>
<li><a href="#test">测试</a></li>
<li><a href="#blueprint">Flask Buleprint</a></li>
<li><a href="#deploy">部署</a></li>
<li><a href="#jinja">Jinja模板</a></li>
</ol>

<h2 id="架构">架构</h2>

<ol>
<li>flask-microservices-main - Docker Compose 文件、Nginx、Admin 脚本</li>
<li>flask-microservices-users - 管理用户和认证的Flask 应用</li>
<li>flask-microservices-client - 客户端，React 应用</li>
<li>flask-microservices-swagger - Swagger API 文档</li>
</ol>

<h2 id="第一部分">第一部分</h2>

<h3 id="1-介绍-a-id-introduction-a">1. 介绍<a id="introduction"></a></h3>

<p>项目中提到的代码都已经归档到<a href="https://github.com/cnych/flask-microservices-users/">github</a>上了，根据课程章节打上了<strong>tag</strong>。本课程是根据<a href="https://realpython.com/tdd/">RealPython的课程</a>进行改编的，希望看原版的就不要呆在这里骂我了，不送。</p>

<p>在第一部分，你将能够学习使用<code>Docker</code>、 <code>Flask</code>、<code>MySQL</code>来创建<code>RESTful</code> API服务。</p>

<blockquote>
<p>我们将采取一种实用的方法来进行测试驱动开发(TDD)</p>
</blockquote>

<p>课程开始之前，你应该要熟悉下面的这些主题：</p>

<ol>
<li>Docker - <a href="https://docs.docker.com/engine/getstarted/">Get started with Docker</a></li>
<li>Docker Compose - <a href="https://docs.docker.com/compose/gettingstarted/">Get started with Docker Compose</a></li>
<li>Flask - <a href="https://github.com/mjhea0/flaskr-tdd">Flaskr TDD</a></li>
</ol>

<p>该部分课程使用到的工具库：</p>

<ol>
<li>Python v3.6.4</li>
<li>Flask v0.12.2</li>
<li>Flask-Script v2.0.6</li>
<li>Flask-SQLAlchemy v2.3.2</li>
<li>Flask-Testing v0.7.1</li>
<li>PyMySQL v0.8.0</li>
<li>Gunicorn v19.7.1</li>
<li>Nginx v1.13.0</li>
<li>Docker v17.11.0-ce</li>
<li>Docker Compose v1.14.0</li>
</ol>

<p>本节课程结束，你能学到下面的知识点：</p>

<ol>
<li>用<code>Flask</code>开发一个<code>RESTful</code> API 服务</li>
<li>实践测试驱动开发(TDD)</li>
<li>在本地用<code>Docker</code>和<code>Docker Compose</code>配置和运行服务</li>
<li>将代码挂载到一个容器中去</li>
<li>在容器中运行单元测试和集成测试</li>
<li>不同容器的服务间的交互</li>
<li>在<code>Docker</code>容器中运行<code>Python</code>和<code>Flask</code>应用</li>
<li>安装<code>Flask</code>、<code>Nginx</code>和<code>Gunicorn</code></li>
</ol>

<h3 id="2-开始-a-id-start-a">2. 开始<a id="start"></a></h3>

<p>本节课程我们将初始化项目框架、定义第一个服务。
创建一个新的项目、安装<code>Flask</code>：</p>

<pre><code class="language-shell">$ mkdir flask-microservices-users &amp;&amp; cd flask-microservices-users
$ mkdir project
$ pyenv virtualenv 3.6.4 tdd3
$ pyenv local tdd3
(tdd3)$ pip install Flask==0.12.2
</code></pre>

<p>在project 目录下面添加<code>__init__.py</code>文件，配置第一个路由：</p>

<pre><code class="language-python"># project/__init__.py
from flask import Flask, jsonify

# 初始化app
app = Flask(__name__)


@app.route('/ping', methods=['GET'])
def ping_pong():
    return jsonify({
        'status': 'success',
        'message': 'pong!'
    })
</code></pre>

<p>然后，添加<code>Flask-Script</code>，该依赖包可以用来在命令行管理<code>Flask</code> app:</p>

<pre><code class="language-shell">(tdd3)$ pip install Flask-Script==2.0.6
</code></pre>

<p>在项目根目录下面添加一个<code>manage.py</code>的文件：</p>

<pre><code class="language-python"># manage.py
from flask_script import Manager
from project import app

manager = Manager(app)

if __name__ == '__main__':
    manager.run()
</code></pre>

<p>这里我们创建了一个新的<code>Manager</code>实例，来处理所有的从命令行输入的命令。运行程序：</p>

<pre><code class="language-shell">(tdd3)$ python manage.py runserver
</code></pre>

<p>运行成功后，我们可以在浏览器中打开页面<code>http://localhost:5000/ping</code>，你能看到如下的json 信息输出到页面：</p>

<pre><code class="language-json">{
  &quot;message&quot;: &quot;pong!&quot;,
  &quot;status&quot;: &quot;success&quot;
}
</code></pre>

<p>然后我们关掉服务(Ctrl+C)，在<code>project</code>目录下面新增一个<code>config.py</code>的文件，内容如下：</p>

<pre><code class="language-python"> #project/config.py
class BaseConfig:
    &quot;&quot;&quot;基础配置&quot;&quot;&quot;
    DEBUG = False
    TESTING = False

class DevelopmentConfig(BaseConfig):
    &quot;&quot;&quot;开发环境配置&quot;&quot;&quot;
    DEBUG = True

class TestingConfig(BaseConfig):
    &quot;&quot;&quot;测试环境配置&quot;&quot;&quot;
    DEBUG = True
    TESTING = True

class ProductionConfig(BaseConfig):
    &quot;&quot;&quot;生产环境配置&quot;&quot;&quot;
    DEBUG = False
</code></pre>

<p>然后更新<code>__init__.py</code>文件，在初始化的时候配置开发环境：</p>

<pre><code class="language-python">from flask import Flask, jsonify

# 初始化app
app = Flask(__name__)
# 环境配置
app.config.from_object('project.config.DevelopmentConfig')

@app.route('/ping', methods=['GET'])
def ping_pong():
    return jsonify({
        'status': 'success',
        'message': 'pong!'
    })
</code></pre>

<p>然后重新运行程序，我们能在终端中看到<code>debug</code>模式的提示信息：</p>

<pre><code class="language-shell">(tdd3)$ python manage.py runserver
    * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
    * Restarting with stat
    * Debugger is active!
    * Debugger PIN: 770-657-842
</code></pre>

<p>现在当你的代码有任何改动的时候，应用都会自动加载，不需要手动重启了。为了保证我们的项目依赖能实时同步，这里我们将依赖包添加到一个<code>requirements.txt</code>的文件中，我们只需要利用<code>pip</code>的<strong>freeze</strong>命令就可以轻松拿到我们项目的依赖列表了：</p>

<pre><code class="language-shell">(tdd3)$ pip freeze &gt; requirements.txt
</code></pre>

<p>然后看看<code>requirements.txt</code>的内容吧:</p>

<pre><code class="language-txt">Flask==0.12.2
Flask-Script==2.0.6
</code></pre>

<p>最后，在项目根目录下面添加一个<code>.gitignore</code>的文件：</p>

<pre><code class="language-txt">__pycache__
env
</code></pre>

<p>初始化git 仓库，然后提交代码吧~</p>

<h3 id="3-docker-容器-a-id-docker-a">3. Docker 容器<a id="docker"></a></h3>

<p>这节课让我们来容器化<code>Flask</code>应用吧&hellip;&hellip;
首先你需要确保你的环境中已经安装了<code>Docker</code>和<code>Docker Compose</code>：</p>

<pre><code class="language-shell">$ docker -v
Docker version 17.05.0-ce, build 89658be
$ docker-compose -v
docker-compose version 1.14.0, build unknown
</code></pre>

<p>在项目的根目录下面创建一个<code>Dockerfile</code>的文件：</p>

<pre><code class="language-txt">FROM python:3.6.4

# 设置工作目录
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# 添加依赖（利用Docker 的缓存）
ADD ./requirements.txt /usr/src/app/requirements.txt

# 安装依赖
RUN pip install -r requirements.txt

# 添加应用
ADD . /usr/src/app

# 运行服务
CMD python manage.py runserver -h 0.0.0.0
</code></pre>

<p>然后同样的在根目录下面创建一个<code>docker-compose.yml</code>的文件：</p>

<pre><code class="language-txt">version: '2.1'

services:
  users-service:
    container_name: users-service
    build: .
    volumes:
      - '.:/usr/src/app'
    ports:
      - 5001:5000  # 端口暴露（主机端口:容器端口）
</code></pre>

<p>执行上面的配置文件将根据<code>Dockerfile</code>创建一个叫<code>users-service</code>的容器。
&gt; <code>docker-compose.yml</code>文件中的目录是相对路径</p>

<p><code>volumes</code>是用来将代码挂载进容器内部的，对于开发环境你最好这么做，不然你每次更新代码后都需要重新构建容器才会生效，显然这是非常影响效率的。</p>

<p>注意<a href="https://docs.docker.com/compose/compose-file/#compose-and-docker-compatibility-matrix">Docker compose 的文件版本</a>，我们这里用的<strong>2.1</strong>，这其实与你安装的<code>docker-compose</code>版本没有任何关系的，只与<code>Docker</code>版本有关系（通过上面的连接可以选择合适的文件版本），但是文件版本<strong>3.x</strong>和<strong>2.x</strong>还是有很多不同的地方，注意查看文档。</p>

<p>然后我们来构建镜像：</p>

<pre><code class="language-shell">$ docker-compose build
</code></pre>

<p>在第一次构建的时候会花费一点时间，不过别担心，由于<code>Docker</code>会缓存第一次的构建结果，后续的构建都会快得多，构建完成后，启动容器：</p>

<pre><code class="language-shell">$ docker-compose up -d
</code></pre>

<blockquote>
<p><code>-d</code>标记是用来让容器在后台执行的</p>
</blockquote>

<p>然后在浏览器中打开<a href="http://127.0.0.1:5001/ping">http://127.0.0.1:5001/ping</a>，确保你能看到和上面看到的JSON 文件信息。
接下来在<code>docker-compose.yml</code>文件中增加一个环境变量，用来加载开发环境的配置信息：</p>

<pre><code class="language-txt">version: '2.1'

services:
  users-service:
    container_name: users-service
    build: .
    volumes:
    - '.:/usr/src/app'
    ports:
    - 5001:5000 # 端口暴露（主机端口:容器端口）
    environment:
    - APP_SETTINGS=project.config.DevelopmentConfig
</code></pre>

<p>然后更新<code>proejct/__init__.py</code>文件，从环境变量获取应用的配置信息：</p>

<pre><code class="language-python">import os
from flask import Flask, jsonify

# 初始化app
app = Flask(__name__)
# 环境配置
app_settings = os.getenv('APP_SETTINGS')
app.config.from_object(app_settings)

@app.route('/ping', methods=['GET'])
def ping_pong():
    return jsonify({
        'status': 'success',
        'message': 'pong!'
    })
</code></pre>

<p>然后更新容器：</p>

<pre><code class="language-shell">$ docker-compose up -d
</code></pre>

<p>怎么知道我们上面的这种方法就配置成功了呢？我们在<code>project/__init__.py</code>文件下面打印下配置信息：</p>

<pre><code class="language-python">import sys
print(app.config, file=sys.stderr)
</code></pre>

<p>因为我们将代码挂载到容器里面的，所以直接保存就会自动加载，然后查看日志：</p>

<pre><code class="language-shell">$ docker-compose logs -f users-service
</code></pre>

<p><img src="https://blog.qikqiak.com/img/posts/WX20171228-171149.png" alt="print-log" />
从日志中可以看出<strong>DEBUG=True，TESTING=False</strong>，证明是生效的（记得删除日志代码哦~~~）</p>

<h3 id="4-数据库安装配置-a-id-database-a">4. 数据库安装配置<a id="database"></a></h3>

<p>这节课我们将来配置<code>MySQL</code>数据库，启动运行在另外一个容器中，然后把它<code>link</code>到<code>users-service</code>容器中&hellip;&hellip;</p>

<p>增加<code>Flask-SQLAlchemy</code>和<code>PyMySQL</code>到<strong>requirementx.txt</strong>文件中：</p>

<pre><code class="language-txt">Flask-SQLAlchemy==2.3.2
PyMySQL==0.8.0
</code></pre>

<p>当然要记得安装这些依赖包：</p>

<pre><code class="language-shell">(tdd3)$ pip install -r requirements.txt
</code></pre>

<p>然后更新<code>config.py</code>文件，添加<code>SQLAlchemy</code>数据库声明：</p>

<pre><code class="language-python"># project/config.py
import os

class BaseConfig:
    &quot;&quot;&quot;基础配置&quot;&quot;&quot;
    DEBUG = False
    TESTING = False
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevelopmentConfig(BaseConfig):
    &quot;&quot;&quot;开发环境配置&quot;&quot;&quot;
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')

class TestingConfig(BaseConfig):
    &quot;&quot;&quot;测试环境配置&quot;&quot;&quot;
    DEBUG = True
    TESTING = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_TEST_URL')

class ProductionConfig(BaseConfig):
    &quot;&quot;&quot;生产环境配置&quot;&quot;&quot;
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
</code></pre>

<p>接下来更新<code>__init__.py</code>文件，创建一个<code>SQLAlchemy</code>实例然后定义数据模型：</p>

<pre><code class="language-python"># project/__init__.py
import os
import datetime
from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy

# 初始化app
app = Flask(__name__)
# 环境配置
app_settings = os.getenv('APP_SETTINGS')
app.config.from_object(app_settings)

# 初始化数据库
db = SQLAlchemy(app)

class User(db.Model):
    __tablename__ = &quot;users&quot;
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    username = db.Column(db.String(128), nullable=False)
    email = db.Column(db.String(128), nullable=False)
    active = db.Column(db.Boolean(), default=False, nullable=False)
    created_at = db.Column(db.DateTime, nullable=False)

    def __init__(self, username, email):
        self.username = username
        self.email = email
        self.created_at = datetime.datetime.utcnow()

@app.route('/ping', methods=['GET'])
def ping_pong():
    return jsonify({
        'status': 'success',
        'message': 'pong!'
    })
</code></pre>

<p>在<code>project</code>目录下增加一个<code>db</code>的文件夹，在下面新建一个<code>create.sql</code>的文件：</p>

<pre><code class="language-sql">CREATE DATABASE users_prod;
CREATE DATABASE users_dev;
CREATE DATABASE users_test;
</code></pre>

<p>然后在<code>db</code>目录下面添加文件<code>Dockerfile</code>:</p>

<pre><code class="language-dockerfile">FROM mysql:5.6

# 初始化的时候运行create.sql脚本
ADD create.sql /docker-entrypoint-initdb.d
</code></pre>

<p>这里我们继承官方的<code>mysql</code>镜像，在<code>docker-entrypoint-initdb.d</code>目录下面增加一个<strong>SQL</strong>文件，容器在初始化的时候就会执行这个sql文件。</p>

<p>更新<code>docker-compose.yml</code>：</p>

<pre><code class="language-yaml">version: '2.1'

services:
  users-db:
    container_name: users-db
    build: ./project/db
    ports:
      - 3307:3306
    environment:
      - MYSQL_ROOT_PASSWORD=root321
    healthcheck:
      test: exit 0

  users-service:
    container_name: users-service
    build: ./
    volumes:
      - '.:/usr/src/app'
    ports:
      - 5001:5000 # 暴露端口 - 主机:容器
    environment:
      - APP_SETTINGS=project.config.DevelopmentConfig
      - DATABASE_URL=mysql+pymysql://root:root321@users-db:3306/users_dev
      - DATABASE_TEST_URL=mysql+pymysql://root:root321@users-db:3306/users_test
    depends_on:
      users-db:
        condition: service_healthy
    links:
      - users-db
</code></pre>

<p>启动后注入环境变量<code>exit code</code>被发送后容器成功运行起来，<code>MySQL</code>将被绑定在宿主机的3307和容器的3306端口上。注意<code>DATABASE_URL</code>路径中使用的<code>mysql+pymysql</code>，因为我们使用的是<code>python3.6.x</code>版本和<code>pymysql</code>驱动。
检测：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose up -d --build
</code></pre>

<p>然后更新<code>manage.py</code>，增加创建数据库的命令：</p>

<pre><code class="language-python">from flask_script import Manager
from project import app, db

manager = Manager(app)

@manager.command
def recreate_db():
    &quot;&quot;&quot;重新创建数据表.&quot;&quot;&quot;
    db.drop_all()
    db.create_all()
    db.session.commit()

if __name__ == '__main__':
    manager.run()
</code></pre>

<p>新添加了一个<code>recrete_db</code>的命令，我们可以在命令行中执行该命令来将<code>model</code>映射到数据中：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose run users-service python manage.py recreate_db
</code></pre>

<p>如果一切正常的话，上面的命令能够执行成功，然后我们来验证下数据库中是否有对应的数据表了：</p>

<pre><code class="language-shell">(tdd3)$ docker exec -it users-db mysql -uroot -p
Enter password:
</code></pre>

<p>然后输入上面我们环境变量中设置的<code>root321</code>就登录到<code>MySQL</code>数据库中了。然后执行下面的系列命令查看数据库、查看数据表结构：</p>

<pre><code class="language-shell">mysql&gt; show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| users_dev          |
| users_prod         |
| users_test         |
+--------------------+
6 rows in set (0.00 sec)

mysql&gt; use users_dev;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql&gt; show tables;
+---------------------+
| Tables_in_users_dev |
+---------------------+
| users               |
+---------------------+
1 row in set (0.00 sec)

mysql&gt; desc users;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| username   | varchar(128) | NO   |     | NULL    |                |
| email      | varchar(128) | NO   |     | NULL    |                |
| active     | tinyint(1)   | NO   |     | NULL    |                |
| created_at | datetime     | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
5 rows in set (0.00 sec)
</code></pre>

<p>从上面的命令我们可以看到<code>model</code>和我们的数据表已经映射成功了。</p>

<h3 id="5-测试-a-id-test-a">5. 测试<a id="test"></a></h3>

<p>按照我们平时的开发习惯，是不是接下来就应该开发业务代码了？当然这样是可以的，但是我们这里不这样做，我们让测试先行，让测试来驱动开发，这样有什么好处呢？<code>TDD</code>最重要的功能就是保障代码的正确性，能够迅速发现、定位<code>bug</code>。关于<code>TDD</code>更多的知识可以自行<code>Google</code>。提供一个篇<strong>IBM</strong>关于<code>TDD</code>介绍的文章：<a href="https://www.ibm.com/developerworks/cn/linux/l-tdd/">浅谈测试驱动开发（TDD）</a></p>

<p>首先新增测试用的依赖包：<code>Flask-Testing</code>到<code>requirements.txt</code>文件下面（记得用<code>pip</code>命令安装哦~）：</p>

<pre><code class="language-txt">Flask-Testing==0.7.1
</code></pre>

<p>在<code>project</code>目录下面新建一个<code>tests</code>的目录，然后在该目录使用下面新增几个文件：</p>

<pre><code class="language-shell">(tdd3)$ touch __init__.py base.py test_config.py test_users.py
</code></pre>

<p>如果你对<code>Flask-Testing</code>还不太熟悉，在编写测试文件之前，可以先简单查<a href="https://flask-testing.readthedocs.io/en/latest/">看下文档</a>。</p>

<p>更新<code>base.py</code>文件：</p>

<pre><code class="language-python"># project/tests/base.py
from flask_testing import TestCase
from project import app, db

class BaseTestCase(TestCase):
    def create_app(self):
        app.config.from_object('project.config.TestingConfig')
        return app

    def setUp(self):
        db.create_all()
        db.session.commit()

    def tearDown(self):
        db.session.remove()
        db.drop_all()
</code></pre>

<p>首先，我们确保测试使用的数据库<code>URI</code>不是生产环境的，避免对线上环境产生任何影响。每次测试的时候创建数据表、完成后删除表，确保能够清理测试数据。</p>

<blockquote>
<p>必须指定一个create_app 方法，并且返回flask app 实例，如果没有定义将会抛出<strong>NotImplementedError</strong>异常。</p>
</blockquote>

<p>另外在<code>tearDown</code>方法中增加<code>db.session.remove()</code>方法，这样能够确保每次测试完成的时候能够将<code>SQLAlchemy</code>的<strong>session</strong>属性移除掉，在下一次测试的时候始终是一个新的<strong>session</strong>。</p>

<p>更新<code>test_config.py</code>文件：</p>

<pre><code class="language-python">from flask import current_app
from flask_testing import TestCase
from project import app

class TestDevelopmentConfig(TestCase):
    def create_app(self):
        app.config.from_object('project.config.DevelopmentConfig')
        return app

    def test_app_is_development(self):
        self.assertTrue(app.config['SECRET_KEY'] == 'secret')
        self.assertTrue(app.config['DEBUG'] is True)
        self.assertFalse(current_app is None)
        self.assertTrue(
            app.config['SQLALCHEMY_DATABASE_URI'] ==
            'mysql+pymysql://root:root321@users-db:3306/users_dev'
        )

class TestTestingConfig(TestCase):
    def create_app(self):
        app.config.from_object('project.config.TestingConfig')
        return app

    def test_app_is_testing(self):
        self.assertTrue(app.config['SECRET_KEY'] == 'secret')
        self.assertTrue(app.config['DEBUG'])
        self.assertTrue(app.config['TESTING'])
        self.assertFalse(app.config['PRESERVE_CONTEXT_ON_EXCEPTION'])
        self.assertTrue(
            app.config['SQLALCHEMY_DATABASE_URI'] ==
            'mysql+pymysql://root:root321@users-db:3306/users_test'
        )

class TestProductionConfig(TestCase):
    def create_app(self):
        app.config.from_object('project.config.ProductionConfig')
        return app

    def test_app_is_production(self):
        self.assertTrue(app.config['SECRET_KEY'] == 'secret')
        self.assertFalse(app.config['DEBUG'])
        self.assertFalse(app.config['TESTING'])
</code></pre>

<p>更新<code>test_users.py</code>文件：</p>

<pre><code class="language-python">import json
from project.tests.base import BaseTestCase

class TestUserService(BaseTestCase):
    def test_users(self):
        &quot;&quot;&quot;确保ping的服务正常.&quot;&quot;&quot;
        response = self.client.get('/ping')
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 200)
        self.assertIn('pong', data['message'])
        self.assertIn('success', data['status'])
</code></pre>

<p>然后我们在<code>manage.py</code>中新增一个测试的命令行支持：</p>

<pre><code class="language-python">import unittest
from flask_script import Manager
from project import app, db

manager = Manager(app)

@manager.command
def recreate_db():
    &quot;&quot;&quot;重新创建数据表.&quot;&quot;&quot;
    db.drop_all()
    db.create_all()
    db.session.commit()

@manager.command
def test():
    &quot;&quot;&quot;运行测试.&quot;&quot;&quot;
    tests = unittest.TestLoader().discover('project/tests', pattern='test_*.py')
    result = unittest.TextTestRunner(verbosity=2).run(tests)
    if result.wasSuccessful():
        return 0
    return 1

if __name__ == '__main__':
    manager.run()
</code></pre>

<p><code>unittest</code>支持非常简单的测试发现，为了兼容测试发现，所有的测试文件(test_xxx.py)必须是可以从项目的顶级目录导入的包或者模块，我们这里就是利用<code>TestLoader.discover()</code>方法去发现<strong>project/tests</strong>包下面的所有的<strong>test_xxx.py</strong>测试文件。然后使用<strong>TextTestRunner</strong>用来执行测试用例，对测试进行编排并把结果返回给用户。</p>

<p>如果是单个的测试文件，推荐你将所有的测试方法放在同一个文件中，这样能够方便的使用<code>unittest.main()</code>方法执行：</p>

<pre><code class="language-python">import unittest
import flask_testing

# TODO your test cases

if __name__ == '__main__':
    unittest.main()
</code></pre>

<p>然后可以执行命令<strong>python test_xxx.py</strong>进行测试。</p>

<p>由于我们这里有<code>test_config.py</code>和<code>test_users.py</code>两个业务不一样的测试用例，所以我们使用<code>test runner</code>来收集所有的测试结果并生成最后的测试报告。</p>

<p>由于我们更新了依赖包，所以需要重新构建镜像：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose up -d --build
</code></pre>

<p>然后运行测试命令：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose run users-service python manage.py test
</code></pre>

<p>然后我们可以看到类似于下面的测试没有通过的提示信息：</p>

<pre><code class="language-shell">======================================================================
FAIL: test_app_is_development (test_config.TestDevelopmentConfig)
----------------------------------------------------------------------
Traceback (most recent call last):
  File &quot;/usr/src/app/project/tests/test_config.py&quot;, line 13, in test_app_is_development
    self.assertTrue(app.config['SECRET_KEY'] == 'secret')
AssertionError: False is not true
</code></pre>

<p>这是因为我们的<code>app.config</code>中还没有<strong>SECRET_KEY</strong>这个key，所以<code>assertTrue</code>肯定是不会通过的，然后我们在<code>config.py</code>的<strong>BaseConfig</strong>类中新增<strong>SECRET_KEY</strong>属性：</p>

<pre><code class="language-python">class BaseConfig:
    &quot;&quot;&quot;基础配置&quot;&quot;&quot;
    DEBUG = False
    TESTING = False
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SECRET_KEY = 'secret'
</code></pre>

<p>重新执行测试命令：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose run users-service python manage.py test
Starting users-db ... done
test_app_is_development (test_config.TestDevelopmentConfig) ... ok
test_app_is_production (test_config.TestProductionConfig) ... ok
test_app_is_testing (test_config.TestTestingConfig) ... ok
test_users (test_users.TestUserService)
确保ping的服务正常. ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.124s

OK
</code></pre>

<p>测试通过~~~</p>

<h3 id="6-flask-blueprint-蓝图-a-id-blueprint-a">6. Flask Blueprint(蓝图)<a id="blueprint"></a></h3>

<p>上节课完成了我们的基本测试，这节课我们来用<code>Blueprint</code>(蓝图)来对项目进行重构。</p>

<blockquote>
<p>还不了解<code>Blueprint</code>？可以先查看<a href="http://flask.pocoo.org/docs/0.12/blueprints/">flask blueprint 文档</a>。简单来说，<code>Flask Blueprint</code>提供了模块化管理程序路由的功能，使程序结构清晰、简单易懂。还是不太明白？没关系，这节课完成后我相信你一定会明白的~</p>
</blockquote>

<p>在<code>project</code>目录下面新增<code>api</code>的目录，然后同样的需要在该目录下面新建几个文件：</p>

<pre><code class="language-shell">(tdd3)$ touch __init__.py models.py views.py
</code></pre>

<p>然后更新<code>views.py</code>文件：</p>

<pre><code class="language-python"># project/api/views.py
from flask import Blueprint, jsonify

users_blueprint = Blueprint('users', __name__)

@users_blueprint.route('/ping', methods=['GET'])
def ping_pong():
    return jsonify({
        'status': 'success',
        'message': 'pong!'
    })
</code></pre>

<p>我们创建了一个<code>users_blueprint</code>的<code>Blueprint</code>实例，然后将该实例绑定到了<code>ping_pong()</code>方法上，这有什么用？继续往下看&hellip;&hellip;</p>

<p>然后更新<code>models.py</code>文件：</p>

<pre><code class="language-python"># project/api/models.py
import datetime
from project import db

class User(db.Model):
    __tablename__ = &quot;users&quot;
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    username = db.Column(db.String(128), nullable=False)
    email = db.Column(db.String(128), nullable=False)
    active = db.Column(db.Boolean(), default=False, nullable=False)
    created_at = db.Column(db.DateTime, nullable=False)

    def __init__(self, username, email):
        self.username = username
        self.email = email
        self.created_at = datetime.datetime.utcnow()
</code></pre>

<p>我们可以看到上面的内容和<code>project/__init__.py</code>文件中的<strong>User</strong>类是一模一样的，没错，我们只是将这个地方代码分拆了而已，下面继续更新<code>project/__init__.py</code>文件：</p>

<pre><code class="language-python"># project/__init__.py
import os
from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy

# 初始化数据库
db = SQLAlchemy()

def create_app():
    # 初始化应用
    app = Flask(__name__)
    # 环境配置
    app_settings = os.getenv('APP_SETTINGS')
    app.config.from_object(app_settings)
    # 安装扩展
    db.init_app(app)
    # 注册blueprint
    from project.api.views import users_blueprint
    app.register_blueprint(users_blueprint)
    return app
</code></pre>

<p>注意这里我们将实例化app的工作提取到了<code>create_app</code>的方法里面去，这是因为<code>users_blueprint</code>里面需要引用到当前文件下面的<code>db</code>实例，如果不把app放置到方法中去的话就会造成<code>循环引用</code>，什么意思？简单来说就是你中有我，我中有你，这对于程序来说是无法做出判断的~</p>

<p>接下来我们需要把所有其他文件引用<code>project</code>下面的app 的实例的都要替换掉(包括测试文件)：</p>

<pre><code class="language-python">from project import create_app

app = create_app()
</code></pre>

<p>更改完成以后我们重新构建镜像、创建数据库，最重要的是什么？<strong>测试</strong>：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose up -d
users-db is up-to-date
Starting users-service ...
Starting users-service ... done
(tdd3)$ docker-compose run users-service python manage.py recreate_db
Starting users-db ... done
(tdd3)$ docker-compose run users-service python manage.py test
Starting users-db ... done
test_app_is_development (test_config.TestDevelopmentConfig) ... ok
test_app_is_production (test_config.TestProductionConfig) ... ok
test_app_is_testing (test_config.TestTestingConfig) ... ok
test_users (test_users.TestUserService)
确保ping的服务正常. ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.086s

OK
</code></pre>

<p>一切正常~~~</p>

<p>接下来我们根据<code>RESTful</code>的最佳实践利用<code>TDD</code>增加3个路由：</p>

<table>
<thead>
<tr>
<th align="left">Endpoint</th>
<th align="left">HTTP Method</th>
<th align="left">CRUD Method</th>
<th align="left">Result</th>
</tr>
</thead>

<tbody>
<tr>
<td align="left">/users</td>
<td align="left">GET</td>
<td align="left">查询</td>
<td align="left">获取所有用户</td>
</tr>

<tr>
<td align="left">/users/:id</td>
<td align="left">GET</td>
<td align="left">查询</td>
<td align="left">获取单个用户</td>
</tr>

<tr>
<td align="left">/users</td>
<td align="left">POST</td>
<td align="left">新增</td>
<td align="left">新增用户</td>
</tr>
</tbody>
</table>

<p>首先，在<code>project/tests/test_users.py</code>文件的<code>TestUserService</code>类中新增一个测试新增用户的方法：</p>

<pre><code class="language-python">def test_add_user(self):
    &quot;&quot;&quot;确保能够正确添加一个用户的用户到数据库中&quot;&quot;&quot;
    with self.client:
        response = self.client.post(
            '/users',
            data=json.dumps(dict(username='cnych', email='qikqiak@gmail.com')),
            content_type='application/json',
        )
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 201)
        self.assertIn('qikqiak@gmail.com was added', data['message'])
        self.assertEqual('success', data['status'])
</code></pre>

<p>我们现在来执行测试肯定是不会通过的，因为路由<code>/users</code>还没实现的，所以接着我们在<code>project/api/views.py</code>中新增一个<code>/users</code>的处理方法：</p>

<pre><code class="language-python"># 注意要导入request
from flask import Blueprint, jsonify, request, render_template

@users_blueprint.route('/users', methods=['POST'])
def add_user():
    # 获取POST的数据
    post_data = request.get_json()
    email = post_data.get('email')
    user = User(username=post_data.get('username'), email=email)
    db.session.add(user)
    db.session.commit()
    response_data = {
        'status': 'success',
        'message': '%s was added!' % email
    }
    return jsonify(response_data), 201
</code></pre>

<blockquote>
<p>注意上面我们<code>add_user</code>方法最终返回的数据，是从我们上面设计的测试代码中来的，这就是所谓的<strong>测试驱动</strong>我们的开发~</p>
</blockquote>

<p>然后执行测试：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose run users-service python manage.py test
Starting users-db ... done
test_app_is_development (test_config.TestDevelopmentConfig) ... ok
test_app_is_production (test_config.TestProductionConfig) ... ok
test_app_is_testing (test_config.TestTestingConfig) ... ok
test_add_user (test_users.TestUserService)
确保能够正确添加一个用户的用户到数据库中 ... ok
test_users (test_users.TestUserService)
确保ping的服务正常. ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.157s

OK
</code></pre>

<p>测试通过~~~</p>

<p>但是还没完呢？现在我们的代码还不够健壮，如果程序中出现了错误或者异常该怎么办呢？比如：</p>

<ol>
<li>POST 的数据为空</li>
<li>POST 的数据无效 - 比如JSON 对象是空的或者包含一个错误的key</li>
<li>如果添加的用户在数据中已经存在？</li>
</ol>

<p>来对这些用例添加一些测试代码：</p>

<pre><code class="language-python">def test_add_user_invalid_json(self):
    &quot;&quot;&quot;如果JSON对象为空，确保抛出一个错误。&quot;&quot;&quot;
    with self.client:
        response = self.client.post(
            '/users',
            data=json.dumps(dict()),
            content_type='application/json'
        )
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 400)
        self.assertIn('Invalid payload', data['message'])
        self.assertEqual('fail', data['status'])

def test_add_user_invalid_json_keys(self):
    &quot;&quot;&quot;如果JSON对象中没有username或email，确保抛出一个错误。&quot;&quot;&quot;
    with self.client:
        response = self.client.post(
            '/users',
            data=json.dumps(dict(email='qikqiak@gmail.com')),
            content_type='application/json'
        )
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 400)
        self.assertIn('Invalid payload', data['message'])
        self.assertEqual('fail', data['status'])

    with self.client:
        response = self.client.post(
            '/users',
            data=json.dumps(dict(username='cnych')),
            content_type='application/json'
        )
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 400)
        self.assertIn('Invalid payload', data['message'])
        self.assertEqual('fail', data['status'])

def test_add_user_duplicate_user(self):
    &quot;&quot;&quot;如果邮件已经存在确保抛出一个错误。&quot;&quot;&quot;
    with self.client:
        self.client.post(
            '/users',
            data=json.dumps(dict(
                username='cnych',
                email='qikqiak@gmail.com'
            )),
            content_type='application/json'
        )
        response = self.client.post(
            '/users',
            data=json.dumps(dict(
                username='cnych',
                email='qikqiak@gmail.com'
            )),
            content_type='application/json'
        )
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 400)
        self.assertIn('Sorry. That email already exists.', data['message'])
        self.assertEqual('fail', data['status'])
</code></pre>

<p>现在我们支持测试命令，肯定是不会通过的，因为还没更新handler 呢：</p>

<pre><code class="language-python">from sqlalchemy import exc

@users_blueprint.route('/users', methods=['POST'])
def add_user():
    # 获取POST的数据
    post_data = request.get_json()
    if not post_data:
        response_data = {
            'status': 'fail',
            'message': 'Invalid payload.'
        }
        return jsonify(response_data), 400
    email = post_data.get('email')
    username = post_data.get('username')
    try:
        user = User.query.filter_by(email=email).first()
        if not user:
            # 证明数据库中不存在该email的用户，可以添加
            db.session.add(User(username=username, email=email))
            db.session.commit()
            response_data = {
                'status': 'success',
                'message': '%s was added!' % email
            }
            return jsonify(response_data), 201
        # 证明该email已经存在
        response_data = {
            'status': 'fail',
            'message': 'Sorry. That email already exists.'
        }
        return jsonify(response_data), 400
    except exc.IntegrityError as e:
        db.session.rollback()  # 出现异常了，回滚
        response_data = {
            'status': 'fail',
            'message': 'Invalid payload.'
        }
        return jsonify(response_data), 400
</code></pre>

<p>然后执行我们的测试命令，现在就能够测试通过了，如果出现了问题那么你应该仔细看看你的代码了~</p>

<p>接下来处理另外两个请求。</p>

<p>获取单个用户信息，还是先进行测试：</p>

<pre><code class="language-python">from project.api.models import User
from project import db

def test_get_user(self):
    user = User(username='cnych', email='qikqiak@gmail.com')
    db.session.add(user)
    db.session.commit()
    with self.client:
        response = self.client.get('/users/%d' % user.id)
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 200)
        self.assertTrue('created_at' in data['data'])
        self.assertEqual('cnych', data['data']['username'])
        self.assertEqual('qikqiak@gmail.com', data['data']['email'])
        self.assertEqual('success', data['status'])
</code></pre>

<p>然后来编写获取单个用户请求的处理函数，更新<code>project/api/views.py</code>文件：</p>

<pre><code class="language-python">@users_blueprint.route('/users/&lt;user_id&gt;', methods=['GET'])
def get_user(user_id):
    &quot;&quot;&quot;获取某用户的详细信息&quot;&quot;&quot;
    user = User.query.filter_by(id=user_id).first()
    response_object = {
        'status': 'success',
        'data': {
            'username': user.username,
            'email': user.email,
            'created_at': user.created_at
        }
    }
    return jsonify(response_object), 200
</code></pre>

<p>现在执行测试命令，测试能够通过了，那应该有哪一些错误处理的场景呢：</p>

<ul>
<li>没有提供id</li>
<li>id不存在</li>
</ul>

<p>然后我们来针对上面两种场景添加测试代码：</p>

<pre><code class="language-python">def test_get_user_no_id(self):
    &quot;&quot;&quot;如果没有id的时候抛出异常。&quot;&quot;&quot;
    with self.client:
        response = self.client.get('/users/xxx')
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 400)
        self.assertIn('Param id error', data['message'])
        self.assertEqual('fail', data['status'])

def test_get_user_incorrect_id(self):
    &quot;&quot;&quot;如果ID不存在则要抛出异常&quot;&quot;&quot;
    with self.client:
        response = self.client.get('/users/-1')
        data = json.loads(response.data.decode())
        self.assertEqual(response.status_code, 404)
        self.assertIn('User does not exist', data['message'])
        self.assertEqual('fail', data['status'])
</code></pre>

<p>然后根据上面我们的测试代码来更新<code>get_user</code>函数：</p>

<pre><code class="language-python">@users_blueprint.route('/users/&lt;user_id&gt;', methods=['GET'])
def get_user(user_id):
    &quot;&quot;&quot;获取某用户的详细信息&quot;&quot;&quot;
    response_object = {
        'status': 'fail',
        'message': 'User does not exist'
    }
    code = 404
    try:
        user = User.query.filter_by(id=int(user_id)).first()
        if user:
            response_object = {
                'status': 'success',
                'data': {
                    'username': user.username,
                    'email': user.email,
                    'created_at': user.created_at
                }
            }
            code = 200
    except ValueError:
        response_object = {
            'status': 'fail',
            'message': 'Param id error'
        }
        code = 400
    finally:
        return jsonify(response_object), code
</code></pre>

<p>然后继续执行我们的测试命令，通过~~~</p>

<p>然后是获取所有的用户列表的请求，这节就让我们的读者朋友自己来动手实践吧，最终代码我们会同步到<code>github</code>上去的，记住要用<code>TDD</code>的思想，先写测试代码，然后编写我们的网络请求函数，然后编写一些异常场景下面的测试代码，继续增强我们的请求函数，再测试。</p>

<p>上面的步骤完成后，我们试着在浏览器中打开<a href="http://127.0.0.1:5001/users">http://127.0.0.1:5001/users</a>接口，不出意外的话我们会看到如下的<code>json</code>信息输出：</p>

<pre><code class="language-json">{
    &quot;data&quot;: {
        &quot;users&quot;: []
    },
    &quot;status&quot;: &quot;success&quot;
}
</code></pre>

<p>这是因为我们的数据库中还没有任何的数据，所以肯定这里得到的是一个空的数据列表。为了测试方便，我们在<code>manage.py</code>文件中增加一个命令来添加一些测试数据吧：</p>

<pre><code class="language-python">from project.api.models import User
@manager.command
def seed_db():
    &quot;&quot;&quot;Seeds the database.&quot;&quot;&quot;
    db.session.add(User(username='cnych', email=&quot;qikqiak@gmail.com&quot;))
    db.session.add(User(username='chyang', email=&quot;icnych@gmail.com&quot;))
    db.session.commit()
</code></pre>

<p>然后我们在命令行中执行<code>seed_db</code>命令：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose run users-service python manage.py seed_db
</code></pre>

<p>执行成功后，我们再次在浏览器中打开上面的接口，已经能够看到用户列表信息了。
<img src="https://blog.qikqiak.com/img/posts/WX20171230-144052.jpg" alt="用户列表" /></p>

<h3 id="7-部署-a-id-deploy-a">7. 部署<a id="deploy"></a></h3>

<p>上面的课程我们已经完成了测试和3个<code>API</code>接口的开发，现在我们来完成部署我们的应用。</p>

<p>首先在项目根目录新建一个<code>docker-compose-prod.yml</code>的文件，将<code>docker-compose.yml</code>文件的内容全部拷贝过来，然后去掉<strong>users-service</strong>下面的<code>volumes</code>，因为这是我们在开发阶段便于调试，将代码挂载到容器中的，生产环境就需要这样做了，然后就是需要将环境变量更改成生产环境的配置：</p>

<pre><code class="language-yaml">environment:
  - APP_SETTINGS=project.config.ProductionConfig
  - DATABASE_URL=mysql+pymysql://root:root321@users-db:3306/users_prod
  - DATABASE_TEST_URL=mysql+pymysql://root:root321@users-db:3306/users_test
</code></pre>

<p>然后执行构建镜像、创建数据库、添加初始数据、测试等命令：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose -f docker-compose-prod.yml up -d --build
(tdd3)$ docker-compose -f docker-compose-prod.yml run users-service python manage.py recreate_db
(tdd3)$ docker-compose -f docker-compose-prod.yml run users-service python manage.py seed_db
(tdd3)$ docker-compose -f docker-compose-prod.yml run users-service python manage.py test
</code></pre>

<p>测试阶段<code>test_app_is_development</code>这个测试用例肯定不会通过的，因为现在是生产环境了。</p>

<p>在生产环境我们一般使用<code>Gunicorn</code>来处理我们的网络请求，在<code>requirements.txt</code>文件中添加<code>gunicorn==19.7.1</code>，然后安装。安装完成后，在<code>docker-compose=prod.yml</code>文件中的<code>users-service</code>区域增加一条命令：</p>

<pre><code class="language-yaml">command: gunicorn -b 0.0.0.0:5000 manage:app
</code></pre>

<p><code>command</code>会覆盖上面<code>Dockerfile</code>中的<code>CMD</code>命令。重新构建镜像：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose -f docker-compose-prod.yml up -d --build
</code></pre>

<p>由于增加了新的依赖文件，所以我们需要重新构建，<code>--build</code>是必须的。</p>

<p>接下来我们来安装<code>Nginx</code>，对我们的网络请求进行反向代理，提高我们接口的性能。在项目根目录下面新建一个<code>nginx</code>的文件夹，然后在该目录中新增<code>Dockerfile</code>文件：</p>

<pre><code class="language-yaml">FROM nginx:1.13.0

RUN rm /etc/nginx/conf.d/default.conf
ADD /flask.conf /etc/nginx/conf.d
</code></pre>

<p>然后在当前目录新建一个<code>flask.conf</code>的文件：</p>

<pre><code class="language-txt">server {
    listen 80;
    location / {
        proxy_pass http://users-service:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
</code></pre>

<p>上面是一个普通的<code>nginx</code>配置文件，意思是从80端口来的请求都转发到后端的<code>http://users-service:5000</code>服务上，后端的<code>users-service</code>服务就是上面我们用<code>gunicorn</code>启动的服务，更多的<code>nginx</code>资料，可以<code>Google</code>查询。</p>

<p>接下来我们将我们的<code>nginx</code>服务添加到<code>docker-compose-prod.yml</code>文件中：</p>

<pre><code class="language-yaml">nginx:
  container_name: nginx
  build: ./nginx/
  restart: always
  ports:
    - 80:80
  depends_on:
    users-service:
      condition: service_started
  links:
    - users-service
</code></pre>

<p>然后将<code>users-service</code>区域中的端口暴露改成只暴露容器端口，因为主机不需要使用了：</p>

<pre><code class="language-yaml">expose:
  - '5000'
</code></pre>

<p>最后重新构建<code>Docker</code>镜像：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose -f docker-compose-prod.yml up -d --build
</code></pre>

<p>构建成功后，我们就可以通过你机器的IP 来访问我们的服务了，如果你有域名的话，就可以将你的域名解析到你主机的IP 上，然后就可以愉快的用域名进行访问我们的服务了，Done!</p>

<h3 id="8-jinja模板-a-id-jinja-a">8. Jinja模板<a id="jinja"></a></h3>

<p>接下来我们学习下服务端模板的使用，在<code>project/api/views/py</code>文件中增加一个新的路由处理函数：</p>

<pre><code class="language-python">from flask import Blueprint, jsonify, request, render_template
@users_blueprint.route('/', methods=['GET'])
def index():
    return render_template('index.html')
</code></pre>

<p>然后更新下<code>Blueprint</code>，增加对模板的配置：</p>

<pre><code class="language-python">users_blueprint = Blueprint('users', __name__, template_folder='./templates')
</code></pre>

<p>然后在<code>project/api</code>目录下增加一个<code>templates</code>的文件夹，在该文件夹下面添加一个<code>index.html</code>的文件：</p>

<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;title&gt;Flask microservice app on Docker&lt;/title&gt;
    &lt;meta name=&quot;author&quot; content=&quot;&quot;&gt;
    &lt;meta name=&quot;description&quot; content=&quot;&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width,initial-scale=1&quot;&gt;
    &lt;!-- styles --&gt;
    &lt;link href=&quot;//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css&quot; rel=&quot;stylesheet&quot;&gt;
    {% block css %}{% endblock %}
&lt;/head&gt;
&lt;body&gt;
&lt;div class=&quot;container&quot;&gt;
  &lt;div class=&quot;row&quot;&gt;
    &lt;div class=&quot;col-md-4&quot;&gt;
      &lt;br&gt;
      &lt;h1&gt;All Users&lt;/h1&gt;
      &lt;hr&gt;&lt;br&gt;
      &lt;form action=&quot;/&quot; method=&quot;POST&quot;&gt;
        &lt;div class=&quot;form-group&quot;&gt;
          &lt;input name=&quot;username&quot; class=&quot;form-control input-lg&quot; type=&quot;text&quot; placeholder=&quot;Enter a username&quot; required&gt;
        &lt;/div&gt;
        &lt;div class=&quot;form-group&quot;&gt;
          &lt;input name=&quot;email&quot; class=&quot;form-control input-lg&quot; type=&quot;email&quot; placeholder=&quot;Enter an email address&quot; required&gt;
        &lt;/div&gt;
        &lt;input type=&quot;submit&quot; class=&quot;btn btn-primary btn-lg btn-block&quot; value=&quot;Submit&quot;&gt;
      &lt;/form&gt;
      &lt;br&gt;
      &lt;hr&gt;
      &lt;div&gt;
        {% if users %}
          {% for user in users %}
            &lt;h4 class=&quot;well&quot;&gt;&lt;strong&gt;{{user.username}}&lt;/strong&gt; - &lt;em&gt;{{user.created_at.strftime('%Y-%m-%d')}}&lt;/em&gt;&lt;/h4&gt;
          {% endfor %}
        {% else %}
          &lt;p&gt;No users!&lt;/p&gt;
        {% endif %}
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;!-- scripts --&gt;
&lt;script src=&quot;https://code.jquery.com/jquery-2.2.4.min.js&quot;&gt;&lt;/script&gt;
{% block js %}{% endblock %}
&lt;/body&gt;
&lt;/html&gt;
</code></pre>

<p>然后再重新构建我们开发环境的镜像：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose -f docker-compose.yml up -d --build
</code></pre>

<p>构建完成后，在浏览器中打开<code>http://127.0.0.1:5001</code>
<img src="https://blog.qikqiak.com/img/posts/flask-index.png" alt="flask-index" /></p>

<p>怎么来测试呢？如果当前数据库中没有任何数据：</p>

<pre><code class="language-python">def test_main_no_users(self):
    &quot;&quot;&quot;没有用户&quot;&quot;&quot;
    response = self.client.get('/')
    self.assertEqual(response.status_code, 200)
    self.assertIn('No users!', response.data)
</code></pre>

<p>添加几条测试数据，获取所有用户列表，添加一条测试用例：</p>

<pre><code class="language-python">def test_main_with_users(self):
    &quot;&quot;&quot;有多个用户的场景&quot;&quot;&quot;
    add_user('cnych', 'icnych@gmail.com')
    add_user('qikqiak', 'qikqiak@gmail.com')
    response = self.client.get('/')
    self.assertEqual(response.status_code, 200)
    self.assertIn(b'All Users', response.data)
    self.assertNotIn(b'No users!', response.data)
    self.assertIn(b'cnych', response.data)
    self.assertIn(b'qikqiak', response.data)
</code></pre>

<p>现在我们执行测试命令，会测试不通过的，因为还没有将用户列表的数据传递到首页，现在来更改<code>project/api/views.py</code>的<code>index</code>处理函数，增加用户列表的获取：</p>

<pre><code class="language-python">@users_blueprint.route('/', methods=['GET'])
def index():
    users = User.query.all()
    return render_template('index.html', users=users)
</code></pre>

<p>这样我们就能够将<code>users</code>列表传递到前端页面<code>index.html</code>中去了，然后可以通过标签<code>{% for user in users %}</code>对每一个用户进行渲染了，现在继续执行测试命令，通过~~~</p>

<p>在上面的前端页面中我们可以看到还有一个表单，用户点击提交按钮后可以添加一个新的用户到数据库中，当然继续我们的测试用例：</p>

<pre><code class="language-python">def test_main_add_user(self):
    &quot;&quot;&quot;前端页面添加一个新的用户&quot;&quot;&quot;
    with self.client:
        response = self.client.post(
            '/',
            data=dict(username='cnych', email='cnych@gmail.com'),
            follow_redirects=True
        )
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'All Users', response.data)
        self.assertNotIn(b'No users!', response.data)
        self.assertIn(b'cnych', response.data)
</code></pre>

<p>上面的测试代码中我们看到<code>self.client.post()</code>函数中多了一个<code>follow_redirects=True</code>参数，这样的话能确保<code>post</code>操作完成后页面能够重新刷新，这样的话首页的用户列表数据就能重新获取渲染了，现在继续执行我们的测试命令，会出现一个错误：</p>

<pre><code class="language-shell">======================================================================
FAIL: test_main_add_user (test_users.TestUserService)
前端页面添加一个新的用户
----------------------------------------------------------------------
Traceback (most recent call last):
  File &quot;/usr/src/app/project/tests/test_users.py&quot;, line 159, in test_main_add_user
    self.assertEqual(response.status_code, 200)
AssertionError: 405 != 200

----------------------------------------------------------------------
</code></pre>

<p>这是因为在首页的路由处理函数<code>index</code>中还没有对<code>POST</code>的请求进行处理，现在我们继续来更新<code>project/api/views.py</code>的<code>index</code>处理函数：</p>

<pre><code class="language-python">@users_blueprint.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        username = request.form['username']
        email = request.form['email']
        db.session.add(User(username=username, email=email))
        db.session.commit()
    users = User.query.order_by(User.created_at.desc()).all()
    return render_template('index.html', users=users)
</code></pre>

<p>上面的函数针对<code>POST</code>的请求进行了数据库添加的操作，注意用户列表我们增加了一条根据<code>created_at</code>进行排序的规则还有<code>methods</code>增加了一个<code>POST</code>，现在再来执行我们的测试代码，通过~~~</p>

<p>上面的测试全部通过过后，现在我们将我们的代码部署到生产环境：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose -f docker-compose-prod.yml up -d --build
</code></pre>

<p>然后执行测试：</p>

<pre><code class="language-shell">(tdd3)$ docker-compose -run users-service python manage.py test
</code></pre>

<p>然后在浏览器中打开<code>http://127.0.0.1</code>(因为生产环境是用<code>Nginx</code>做转发的，所以不用加端口)：
<img src="https://blog.qikqiak.com/img/posts/WX20180102-110111.png" alt="page-index" /></p>

<p><strong>天下大事，合久必分，分久必合</strong>， 下节课我们就来将项目进行拆分~~~</p>

<p>最后还是广告~~~</p>

<p>扫描下面的二维码(或微信搜索<code>iEverything</code>)添加我微信好友(注明python)，然后可以加入到我们的<code>python</code>讨论群里面共同学习
<img src="https://blog.qikqiak.com/img/posts/wexin-qrcode.jpeg" alt="qrcode" /></p>
      </article>

      
        

<h3>相关文章</h3>
<ul style="margin-bottom: 25px;">
    
    <li><a href="https://blog.qikqiak.com/post/python-convert-pdf-images/">利用Python 优雅的将PDF 转换成图片</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/django-staticroot-staticfilesdirs-function/">Django 中STATIC_ROOT 与STATICFILES_DIRS的区别</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/django-decorator-usage/">Python装饰器简介</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/python-process-csv-file/">用python处理csv文件</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/install-docker-registry-harbor-in-kubernetes/">在kubernetes 集群上搭建docker 私有仓库Harbor</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/manual-install-high-available-kubernetes-cluster/">手动搭建高可用的kubernetes 集群</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/webpack3-get-started/">webpack3入门到放肆视频教程</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/a-better-git-log/">更优雅的git log</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/make-https-blog/">给博客加上HTTPS</a></li>
    
    <li><a href="https://blog.qikqiak.com/post/awk-base-compute/">用awk做基本运算</a></li>
    
</ul>

      

      
      <ul class="pager blog-pager">
        
          <li class="previous">
            <a href="https://blog.qikqiak.com/post/alertmanager-of-prometheus-in-practice/" data-toggle="tooltip" data-placement="top" title="Prometheus报警AlertManager实战">&larr; Previous Post</a>
          </li>
        
        
          <li class="next">
            <a href="https://blog.qikqiak.com/post/upgrading-django-20-10-tips/" data-toggle="tooltip" data-placement="top" title="更新django2.0的10条注意事项">Next Post &rarr;</a>
          </li>
        
      </ul>
      

      

      
      <div id="git-comments"></div>
      <link rel="stylesheet" href="https://imsun.github.io/gitment/style/default.css">
      <script src="https://imsun.github.io/gitment/dist/gitment.browser.js"></script>
      <script>
      var gitment = new Gitment({
        id: 'tdd-develop-python-microservice-app',
        owner: 'cnych',
        repo: 'blog',
        oauth: {
          client_id: 'bdb76dbb2e9d0786e350',
          client_secret: 'b454b2a08013fd0e32013be7a63fa8fcb262b6c4',
        }
      })
      gitment.render('git-comments')
      </script>
      

    </div>
  </div>
</div>

    <footer>
  <div class="container">
    <div class="row">
      <div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
        <ul class="list-inline text-center footer-links">
          
              <li>
                <a href="mailto:icnych@gmail.com" title="Email me">
                  <span class="fa-stack fa-lg">
                    <i class="fa fa-circle fa-stack-2x"></i>
                    <i class="fa fa-envelope fa-stack-1x fa-inverse"></i>
                  </span>
                </a>
              </li>
          
              <li>
                <a href="https://github.com/cnych" title="GitHub">
                  <span class="fa-stack fa-lg">
                    <i class="fa fa-circle fa-stack-2x"></i>
                    <i class="fa fa-github fa-stack-1x fa-inverse"></i>
                  </span>
                </a>
              </li>
          
              <li>
                <a href="https://weibo.com/cnych" title="微博">
                  <span class="fa-stack fa-lg">
                    <i class="fa fa-circle fa-stack-2x"></i>
                    <i class="fa fa-weibo fa-stack-1x fa-inverse"></i>
                  </span>
                </a>
              </li>
          
              <li>
                <a href="https://instagram.com/cnych" title="Instagram">
                  <span class="fa-stack fa-lg">
                    <i class="fa fa-circle fa-stack-2x"></i>
                    <i class="fa fa-instagram fa-stack-1x fa-inverse"></i>
                  </span>
                </a>
              </li>
          
          
          <li>
            <a href="https://blog.qikqiak.com/index.xml" title="RSS">
              <span class="fa-stack fa-lg">
                <i class="fa fa-circle fa-stack-2x"></i>
                <i class="fa fa-rss fa-stack-1x fa-inverse"></i>
              </span>
            </a>
          </li>
          
        </ul>
        <p class="credits copyright text-muted">
          

          &nbsp;&bull;&nbsp;
          2018

          
            &nbsp;&bull;&nbsp;
            <a href="https://blog.qikqiak.com/">阳明的博客</a>
          
        </p>
        
        <p class="credits theme-by text-muted">
          <a href="http://gohugo.io">Hugo v0.34</a> powered &nbsp;&bull;&nbsp; Theme by <a href="http://deanattali.com/beautiful-jekyll/">Beautiful Jekyll</a> adapted to <a href="https://github.com/halogenica/beautifulhugo">Beautiful Hugo</a>
          
        </p>
      </div>
    </div>
  </div>
</footer>

<script src="https://cdn.bootcss.com/KaTeX/0.7.1/katex.min.js"></script>
<script src="https://cdn.bootcss.com/KaTeX/0.7.1/contrib/auto-render.min.js"></script>
<script src="https://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>
<script src="https://cdn.bootcss.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="https://blog.qikqiak.com/js/main.min.js"></script>
<script src="https://blog.qikqiak.com/js/highlight.min.js"></script>
<script> hljs.initHighlightingOnLoad(); </script>
<script> renderMathInElement(document.body); </script>
<script src="https://blog.qikqiak.com/js/prism.js?t=123"></script>
<script src="https://cdn.bootcss.com/photoswipe/4.1.1/photoswipe.min.js"></script>
<script src="https://cdn.bootcss.com/photoswipe/4.1.1/photoswipe-ui-default.min.js"></script>
<script src="https://blog.qikqiak.com/js/load-photoswipe.min.js"></script>



<script async src="https://www.googletagmanager.com/gtag/js?id=UA-69668147-3"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'UA-69668147-3');
</script>

  </body>
</html>

